View的进阶,自定义一款自带动画的雷达图
/ 今日科技快讯 /
近日工信部发放四张5G商用牌照,中国联通、中国移动、中国电信和中国广播电视网络有限公司各得到一张牌照。中国的5G商用元年正式开启。5G牌照的发放,将推升产业链的成熟、加快网络部署。5G网络的建设从前期的测试阶段,逐步过渡到商用服务阶段。
/ 作者简介 /
大家周一早上好,美好的一周又开始了!
本篇文章来自老司机二娃_的投稿,分享了一款带动画的雷达图,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。
二娃_的博客地址:
https://juejin.im/user/5bf67660e51d45218f3d0938
/ 直接上图 /
支持XML自定义属性:
rv_webRadius:雷达网的半径(该属性决定了View的宽高)
rv_webMaxProgress:各属性表示的最大进度
rv_webLineColor:雷达网的颜色
rv_webLineWidth:雷达网的线宽
rv_textArrayedColor:各属性文字的颜色
rv_textArrayedFontPath:各属性文字和中心处名字的字体路径
rv_areaColor:中心连接区域的颜色
rv_areaBorderColor:中心连接区域的边框颜色
rv_textCenteredName:中心处的名字
rv_textCenteredColor:中心文字的颜色
rv_textCenteredFontPath:中心数字文字的字体路径
rv_animateTime:动画执行时间
rv_animateMode:动画模式
TIME:时间一定,动画执行时间为rv_animateTime
SPEED:速度一定,动画执行速度为rv_webMaxProgress➗rv_animateTime
支持代码设置数据源:
setTextArray(textList: List<String>):设置各属性文字数组,元素个数不能小于3
setProgressList(progressList: List<Int>):设置各属性对应的进度数组,该数组元素默认都是0,元素个数必须与文字数组保持一致
setOldProgressList(oldProgressList: List<Int>):设置各属性执行动画前,对应的进度数组,该数组元素默认都是0,元素个数必须与文字数组保持一致
支持代码执行动画:
doInvalidate():各个属性的动画一起执行
doInvalidate(index: Int, block: ((Int) -> Unit)? = null):指定某属性执行动画,可传入参数接收动画结束的回调
/ 起源 /
近来我司产品侧在重构一项业务,连带UI也有变动,其中就涉及到了雷达图,所以也就有了这次封装的RadarView,其主要特点是:
有丰富的自定义属性,可对雷达图外观进行设置
支持自由设置属性个数
支持两种动画模式(时间一定、速度一定)
支持指定某属性执行动画(从而满足UI稿的个性需求,见头图三)
头图三联是演示了该View的主要特点,然后结合局部UI稿,大家可以对比看下(还原度99%,✧(≖ ◡ ≖✿)嘿嘿嘿)。
/ 思考分析 /
NOTE:
我们把六角形抽象成N角形,后文统一使用N角形表示
我们在绘制前会把坐标系原点移动到雷达图中心,后文统一使用原点表示
我们先来思考下关键技术点:
绘制N角形雷达网
在绘制完一条虚线后,紧接着绘制实线
同样以雷达图中心为原点,将坐标系每向上移动雷达网半径/4后,并且顺时针旋转360/N/2度(为什么是这个值?大家可自行🤔下),此时的坐标系x轴刚好与对应的实线重合
所以,直接从移动旋转后的新坐标系原点沿x轴绘制实线即可
实线长度通过雷达网半径和相应角度的三角函数等可以算出来
虚线可以给Paint设置DashPathEffect实现
以雷达图中心为原点,将坐标系每逆时针旋转360/N度,从原点向上绘制长度为雷达网半径的虚线
绘制虚线
绘制实线
绘制N个角的属性文字
以雷达图中心为原点,以12点方向的角为第一个角,可以知道它的顶点坐标(0,-半径)
通过圆上一点绕圆心(坐标原点)顺时针旋转α弧度得出另一点坐标的坐标公式,可以算出各角顶点的坐标(后面会推导该公式!)
再结合文字与顶点的间距、Paint的setTextAlign()、以及微调绘制文字时的Y坐标就可以搞定啦
绘制中心连接区域
同样以12点方向属性(角)上的值的为第一个点,可以知道该点坐标为(0,-半径✖进度️)
同样通过坐标公式可以算出各属性的进度值坐标
使用Path连接各点构建出路径,然后绘制与描边就很好说啦
绘制中心数字&名字
就是很基本的绘制文字,但需要注意微调Y坐标,以提高与UI稿的还原度(细节!细节!细节!)
添加动画效果
动画的本质就是不断调整各属性值的坐标,然后重绘View
实际代码我们使用属性动画ValueAnimator可以搞定,也很简单
整理下思路框架:
定义、初始化属性
自定义属性部分
计算属性部分
绘制依赖的属性部分
定义设置属性的API
绘制N角形雷达网
绘制N个角的属性文字
绘制中心连接区域
绘制中心数字&名字
添加动画效果
技术点、思路理好了,按道理就要着手开始代码了,不过我们先上道数学题热热身。
/ 热身数学题 /
请听题:根据9年义务教育所学,推导出通过圆上一点绕圆心(坐标原点)顺时针旋转α弧度得出另一点坐标的坐标公式
设圆半径为r,圆上有一点坐标A(
推导过程如下:
数学上规定逆时针旋转为正方向
当一条射线从x轴的正方向(向右)开始逆时针方向旋转之后到了一个新位置(按顺序分别到达第一、二、三、四象限)得一个角.为了方便规定这个角是正角,所以逆时针方向也就相应规定为正方向了.
所以为了我们在代码中以顺时针为正方向,我们将
/ 封装公式 /
现在我们就把上面的公式封装成工具代码,这可谓是一『利器』,日后我们自定义View中也会经常用到!
首先,我们要把360°的角度制(degree)转化为弧度制(radian),这样我们在绘制时直接使用角度制会方便很多。
/**
* 角度制转弧度制
*/
private fun Float.degree2radian(): Float {
return (this / 180f * PI).toFloat()
}
/**
* 计算某角度的sin值
*/
fun Float.degreeSin(): Float {
return sin(this.degree2radian())
}
/**
* 计算某角度的cos值
*/
fun Float.degreeCos(): Float {
return cos(this.degree2radian())
}
然后,根据公式写代码即可得到我们的『利器』。这里我们需要外部传入PointF实例,而不是每次创建,以提升性能
/**
* 计算一个点坐标,绕原点旋转一定角度后的坐标
*/
fun PointF.degreePointF(outPointF: PointF, degree: Float) {
outPointF.x = this.x * degree.degreeCos() - this.y * degree.degreeSin()
outPointF.y = this.y * degree.degreeCos() + this.x * degree.degreeSin()
}
/ 绘制过程 /
1. 定义、初始化属性
1.1 自定义属性部分
这一步比较容易,在attrs.xml中定义我们的属性,在Layout中声明变量,并做初始化即可。这里我们就只贴出声明变量的代码。
//********************************
//* 自定义属性部分
//********************************
/**
* 雷达网图半径
*/
private var mWebRadius: Float = 0f
/**
* 雷达网图半径对应的最大进度
*/
private var mWebMaxProgress: Int = 0
/**
* 雷达网线颜色
*/
@ColorInt
private var mWebLineColor: Int = 0
/**
* 雷达网线宽度
*/
private var mWebLineWidth: Float = 0f
/**
* 雷达图各定点文字颜色
*/
@ColorInt
private var mTextArrayedColor: Int = 0
/**
* 雷达图文字数组字体路径
*/
private var mTextArrayedFontPath: String? = null
/**
* 雷达图中心连接区域颜色
*/
@ColorInt
private var mAreaColor: Int = 0
/**
* 雷达图中心连接区域边框颜色
*/
@ColorInt
private var mAreaBorderColor: Int = 0
/**
* 雷达图中心文字名称
*/
private var mTextCenteredName: String = default_textCenteredName
/**
* 雷达图中心文字颜色
*/
@ColorInt
private var mTextCenteredColor: Int = 0
/**
* 雷达图中心文字字体路径
*/
private var mTextCenteredFontPath: String? = null
/**
* 文字数组,且以该数组长度确定雷达图是几边形
*/
private var mTextArray: Array<String> by Delegates.notNull()
/**
* 进度数组,与TextArray一一对应
*/
private var mProgressArray: Array<Int> by Delegates.notNull()
/**
* 执行动画前的进度数组,与TextArray一一对应
*/
private var mOldProgressArray: Array<Int> by Delegates.notNull()
/**
* 动画时间,为0代表没有动画
* NOTE: 如果是速度一定模式下,代表从雷达中心执行动画到顶点的时间
*/
private var mAnimateTime: Long = 0L
/**
* 动画模式,默认为时间一定模式
*/
private var mAnimateMode: Int = default_animateMode
1.2 计算属性部分
所谓计算属性就是我们要通过某自定义属性为基础计算得来的属性。举个🌰:各属性描述文字的字体大小,此处我们使用雷达图半径✖UI稿的比例得来,其它属性也同理,代码如下:
//********************************
//* 计算属性部分
//********************************
/**
* 垂直文本距离雷达主图的宽度
*/
private var mVerticalSpaceWidth: Float by Delegates.notNull()
/**
* 水平文本距离雷达主图的宽度
*/
private var mHorizontalSpaceWidth: Float by Delegates.notNull()
/**
* 文字数组中的字体大小
*/
private var mTextArrayedSize: Float by Delegates.notNull()
/**
* 文字数组设置字体大小后的文字宽度,取字数最多的
*/
private var mTextArrayedWidth: Float by Delegates.notNull()
/**
* 文字数组设置字体大小后的文字高度
*/
private var mTextArrayedHeight: Float by Delegates.notNull()
/**
* 该View的宽度
*/
private var mWidth: Float by Delegates.notNull()
/**
* 该View的高度
*/
private var mHeight: Float by Delegates.notNull()
/**
* 初始化计算属性,基本的宽高、字体大小、间距等数据
* NOTE:以UI稿比例为准,根据[mWebRadius]来计算
*/
private fun initCalculateAttributes() {
//根据比例计算相应属性
(mWebRadius / 100).let {
mVerticalSpaceWidth = it * 8
mHorizontalSpaceWidth = it * 10
mTextArrayedSize = it * 12
}
//设置字体大小后,计算文字所占宽高
mPaint.textSize = mTextArrayedSize
mTextArray.maxBy { it.length }?.apply {
mTextArrayedWidth = mPaint.measureText(this)
mTextArrayedHeight = mPaint.fontSpacing
}
mPaint.utilReset()
//动态计算出view的实际宽高
mWidth = (mTextArrayedWidth + mHorizontalSpaceWidth + mWebRadius) * 2.1f
mHeight = (mTextArrayedHeight + mVerticalSpaceWidth + mWebRadius) * 2.1f
}
1. 3 绘制依赖的属性部分
绘制依赖的属性就是我们在实际绘制时需要使用的全局属性,我们会提前初始化他们,这样就可以复用,避免在draw()方法中每次都new对象开辟内存。
假如我们在draw()方法中new了Paint对象,AS也会提示警告我们,截图和翻译如下⚠️:
Avoid object allocations during draw/layout operations (preallocate and reuse instead) less... (⌘F1)
避免在绘制和布局期间创建对象,采用提前创建和能复用的方式代替
Inspection info:You should avoid allocating objects during a drawing or layout operation.
These are called frequently, so a smooth UI can be interrupted by garbage collection pauses caused by the object allocations.
你应该避免在绘制和布局期间创建对象。他们会被频繁执行,因此,平滑的UI会被对象分配导致的垃圾收集暂停中断。
The way this is generally handled is to allocate the needed objects up front and to reuse them for each drawing operation.
一般的处理方式就是提前初始化需要的对象并在每次绘制操作时复用它们。
Some methods allocate memory on your behalf (such as Bitmap.create), and these should be handled in the same way.
有些方法替你分配了内存(比如Bitmap.create),这些方法应该采用相同的处理方式。
Issue id: DrawAllocation
所以我们把画笔、Path、存放坐标的数组等全局声明并初始化,代码如下:
//********************************
//* 绘制使用的属性部分
//********************************
/**
* 全局画笔
*/
private val mPaint = createPaint()
private val mHelperPaint = createPaint()
/**
* 全局路径
*/
private val mPath = Path()
/**
* 雷达网虚线效果
*/
private var mDashPathEffect: DashPathEffect by Delegates.notNull()
/**
* 雷达主图各顶点的坐标数组
*/
private var mPointArray: Array<PointF> by Delegates.notNull()
/**
* 文字数组各文字的坐标数组
*/
private var mTextArrayedPointArray: Array<PointF> by Delegates.notNull()
/**
* 文字数组各进度的坐标数组
*/
private var mProgressPointArray: Array<PointF> by Delegates.notNull()
/**
* 作转换使用的临时变量
*/
private var mTempPointF: PointF = PointF()
/**
* 雷达图文字数组字体
*/
private var mTextArrayedTypeface: Typeface? = null
/**
* 雷达图中心文字字体
*/
private var mTextCenteredTypeface: Typeface? = null
/**
* 动画处理器数组
*/
private var mAnimatorArray: Array<ValueAnimator?> by Delegates.notNull()
/**
* 各雷达属性动画的时间数组
*/
private var mAnimatorTimeArray: Array<Long> by Delegates.notNull()
/**
* 初始化绘制相关的属性
*/
private fun initDrawAttributes() {
context.dpf2pxf(2f).run {
mDashPathEffect = DashPathEffect(floatArrayOf(this, this), this)
}
mPointArray = Array(mTextArray.size) { PointF(0f, 0f) }
mTextArrayedPointArray = Array(mTextArray.size) { PointF(0f, 0f) }
mProgressPointArray = Array(mTextArray.size) { PointF(0f, 0f) }
if (mTextArrayedFontPath != null) {
mTextArrayedTypeface = Typeface.createFromAsset(context.assets, mTextArrayedFontPath)
}
if (mTextCenteredFontPath != null) {
mTextCenteredTypeface = Typeface.createFromAsset(context.assets, mTextCenteredFontPath)
}
}
1.4 定义设置属性的API
这里我们分别暴露设置文字数组、属性进度数组、执行动画前的进度数组三个API
//********************************
//* 设置数据属性部分
//********************************
fun setTextArray(textList: List<String>) {
this.mTextArray = textList.toTypedArray()
this.mProgressArray = Array(mTextArray.size) { 0 }
this.mOldProgressArray = Array(mTextArray.size) { 0 }
initView()
}
fun setProgressList(progressList: List<Int>) {
this.mProgressArray = progressList.toTypedArray()
initView()
}
/**
* 设置执行动画前的进度
*/
fun setOldProgressList(oldProgressList: List<Int>) {
this.mOldProgressArray = oldProgressList.toTypedArray()
initView()
}
2. 绘制N角形雷达网
我们在绘制前整体将坐标系原点移动到View的中心处,这样很便于之后的绘制。如下:
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (canvas == null) return
canvas.helpGreenCurtain(debug)
canvas.save()
canvas.translate(mWidth / 2, mHeight / 2)
//此处做数据校验
if (checkIllegalData(canvas)) {
//绘制网状图形
drawWeb(canvas)
//绘制文字数组
drawTextArray(canvas)
//绘制连接区域
drawConnectionArea(canvas)
//绘制中心的文字
drawCenterText(canvas)
}
canvas.restore()
}
然后就是绘制N角形网了,代码就是我们先前思路的具体体现。
/**
* 绘制网状图形
*/
private fun drawWeb(canvas: Canvas) {
canvas.save()
val rDeg = 360f / mTextArray.size
mTextArray.forEachIndexed { index, _ ->
//绘制虚线,每次都将坐标系逆时针旋转(rDeg * index)度
canvas.save()
canvas.rotate(-rDeg * index)
mPaint.pathEffect = mDashPathEffect
mPaint.color = mWebLineColor
mPaint.strokeWidth = mWebLineWidth
canvas.drawLine(0f, 0f, 0f, -mWebRadius, mPaint)
mPaint.utilReset()
//用三角函数计算出最长的网的边
val lineW = mWebRadius * (rDeg / 2).degreeSin() * 2
for (i in 1..4) {
//绘制网的边,每次将坐标系向上移动(mWebRadius / 4f)*i,
//且顺时针旋转(rDeg / 2)度,然后绘制长度为(lineW / 4f * i)的实线
canvas.save()
canvas.translate(0f, -mWebRadius / 4f * i)
canvas.rotate(rDeg / 2)
mPaint.color = mWebLineColor
mPaint.strokeWidth = mWebLineWidth
canvas.drawLine(0f, 0f, lineW / 4f * i, 0f, mPaint)
mPaint.utilReset()
canvas.restore()
}
canvas.restore()
}
canvas.restore()
}
3. 绘制N个角的属性文字
这一步除了用代码实现我们先前的思路外,也有关于文字位置的整体处理与微调处理,这样一顿猛如虎的操作之后我们的还原度才能更上一层楼。
/**
* 绘制文字数组
*/
private fun drawTextArray(canvas: Canvas) {
canvas.save()
val rDeg = 360f / mTextArray.size
//先计算出雷达图各个顶点的坐标
mPointArray.forEachIndexed { index, pointF ->
if (index == 0) {
pointF.x = 0f
pointF.y = -mWebRadius
} else {
mPointArray[index - 1].degreePointF(pointF, rDeg)
}
//绘制辅助圆点
if (debug) {
mHelperPaint.color = Color.RED
canvas.drawCircle(pointF.x, pointF.y, 5f, mHelperPaint)
mHelperPaint.utilReset()
}
}
//基于各顶点坐标,计算出文字坐标并绘制文字
mTextArrayedPointArray.mapIndexed { index, pointF ->
pointF.x = mPointArray[index].x
pointF.y = mPointArray[index].y
return@mapIndexed pointF
}.forEachIndexed { index, pointF ->
mPaint.color = mTextArrayedColor
mPaint.textSize = mTextArrayedSize
if (mTextArrayedTypeface != null) {
mPaint.typeface = mTextArrayedTypeface
}
when {
index == 0 -> {
//微调修正文字y坐标
pointF.y += mPaint.getBottomedY()
pointF.y = -(pointF.y.absoluteValue + mVerticalSpaceWidth)
mPaint.textAlign = Paint.Align.CENTER
}
mTextArray.size / 2f == index.toFloat() -> {
//微调修正文字y坐标
pointF.y += mPaint.getToppedY()
pointF.y = (pointF.y.absoluteValue + mVerticalSpaceWidth)
mPaint.textAlign = Paint.Align.CENTER
}
index < mTextArray.size / 2f -> {
//微调修正文字y坐标
if (pointF.y < 0) {
pointF.y += mPaint.getBottomedY()
} else {
pointF.y += mPaint.getToppedY()
}
pointF.x = (pointF.x.absoluteValue + mHorizontalSpaceWidth)
mPaint.textAlign = Paint.Align.LEFT
}
index > mTextArray.size / 2f -> {
//微调修正文字y坐标
if (pointF.y < 0) {
pointF.y += mPaint.getBottomedY()
} else {
pointF.y += mPaint.getToppedY()
}
pointF.x = -(pointF.x.absoluteValue + mHorizontalSpaceWidth)
mPaint.textAlign = Paint.Align.RIGHT
}
}
canvas.drawText(mTextArray[index], pointF.x, pointF.y, mPaint)
mPaint.utilReset()
}
canvas.restore()
}
4. 绘制中心连接区域
这一步也算是驾轻就熟的操作了,根据各属性进度以及坐标公式,得出各点坐标,然后构建Path,绘制即可。
/**
* 绘制雷达连接区域
*/
private fun drawConnectionArea(canvas: Canvas) {
canvas.save()
val rDeg = 360f / mTextArray.size
//根据雷达图第一个坐标最为基坐标进行相应计算,算出各个进度坐标
val bPoint = mPointArray.first()
mProgressPointArray.forEachIndexed { index, pointF ->
val progress = mProgressArray[index] / mWebMaxProgress.toFloat()
pointF.x = bPoint.x * progress
pointF.y = bPoint.y * progress
pointF.degreePointF(mTempPointF, rDeg * index)
pointF.x = mTempPointF.x
pointF.y = mTempPointF.y
//绘制辅助圆点
if (debug) {
mHelperPaint.color = Color.BLACK
canvas.drawCircle(pointF.x, pointF.y, 5f, mHelperPaint)
mHelperPaint.utilReset()
}
//使用路径连接各个点
if (index == 0) {
mPath.moveTo(pointF.x, pointF.y)
} else {
mPath.lineTo(pointF.x, pointF.y)
}
if (index == mProgressPointArray.lastIndex) {
mPath.close()
}
}
//绘制区域路径
mPaint.color = mAreaColor
canvas.drawPath(mPath, mPaint)
mPaint.utilReset()
//绘制区域路径的边框
mPaint.color = mAreaBorderColor
mPaint.style = Paint.Style.STROKE
mPaint.strokeWidth = mWebLineWidth
mPaint.strokeJoin = Paint.Join.ROUND
canvas.drawPath(mPath, mPaint)
mPath.reset()
mPaint.utilReset()
canvas.restore()
}
5. 绘制中心数字&名字
这一步也是常规操作,唯一需注意的也是对文字位置的微调,提高还原度
/**
* 绘制中心文字
*/
private fun drawCenterText(canvas: Canvas) {
canvas.save()
//绘制数字
mPaint.color = mTextCenteredColor
mPaint.textSize = mTextArrayedSize / 12 * 20
mPaint.textAlign = Paint.Align.CENTER
if (mTextCenteredTypeface != null) {
mPaint.typeface = mTextCenteredTypeface
}
//将坐标系向下微调移动
canvas.translate(0f, mPaint.fontMetrics.bottom)
var sum = mProgressArray.sum().toString()
//添加辅助文本
if (debug) {
sum += "ajk你好"
}
canvas.drawText(sum, 0f, mPaint.getBottomedY(), mPaint)
mPaint.utilReset()
//绘制名字
mPaint.color = mTextCenteredColor
mPaint.textSize = mTextArrayedSize / 12 * 10
mPaint.textAlign = Paint.Align.CENTER
if (mTextArrayedTypeface != null) {
mPaint.typeface = mTextArrayedTypeface
}
canvas.drawText(mTextCenteredName, 0f, mPaint.getToppedY(), mPaint)
mPaint.utilReset()
//绘制辅助线
if (debug) {
mHelperPaint.color = Color.RED
mHelperPaint.strokeWidth = context.dpf2pxf(1f)
canvas.drawLine(-mWidth, 0f, mWidth, 0f, mHelperPaint)
mHelperPaint.utilReset()
}
canvas.restore()
}
6. 添加动画效果
动画的本质就是不断调整各属性值的坐标,然后重绘View
在我们的代码中各属性的坐标又是根据属性进度得来的,所以不断调整各属性进度就能产生动画。
在初始化操作时要提前初始化动画处理器
/**
* 初始化动画处理器
*/
private fun initAnimator() {
mAnimatorArray = Array(mTextArray.size) { null }
mAnimatorTimeArray = Array(mTextArray.size) { 0L }
mAnimatorArray.forEachIndexed { index, _ ->
val sv = mOldProgressArray[index].toFloat()
val ev = mProgressArray[index].toFloat()
mAnimatorArray[index] = if (sv == ev) null else ValueAnimator.ofFloat(sv, ev)
if (mAnimateMode == ANIMATE_MODE_TIME) {
mAnimatorTimeArray[index] = mAnimateTime
} else {
//根据最大进度和动画时间算出恒定速度
val v = mWebMaxProgress.toFloat() / mAnimateTime
mAnimatorTimeArray[index] = if (sv == ev) 0L else ((ev - sv) / v).toLong()
}
}
}
/**
* 各属性动画一起执行
*/
fun doInvalidate() {
mAnimatorArray.forEachIndexed { index, _ ->
doInvalidate(index)
}
}
/**
* 指定某属性开始动画
*/
fun doInvalidate(index: Int, block: ((Int) -> Unit)? = null) {
if (index >= 0 && index < mAnimatorArray.size) {
val valueAnimator = mAnimatorArray[index]
val at = mAnimatorTimeArray[index]
if (valueAnimator != null && at > 0) {
valueAnimator.duration = at
valueAnimator.removeAllUpdateListeners()
valueAnimator.addUpdateListener {
val av = (it.animatedValue as Float)
mProgressArray[index] = av.toInt()
invalidate()
}
//设置动画结束监听
if (block != null) {
valueAnimator.removeAllListeners()
valueAnimator.addListener(object : Animator.AnimatorListener {
override fun onAnimationRepeat(animation: Animator?) {}
override fun onAnimationEnd(animation: Animator?) {
block.invoke(index)
}
override fun onAnimationCancel(animation: Animator?) {}
override fun onAnimationStart(animation: Animator?) {}
})
}
valueAnimator.start()
} else {
block?.invoke(index)
}
}
}
开箱即用源码地址:
https://github.com/drawf/SourceSet/blob/master/app/src/main/java/me/erwa/sourceset/view/RadarView.kt
推荐阅读:
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注